第11章 期约与异步函数

ES6 新增了 Promise(期约) 引用类型,支持优雅地定义和组织异步逻辑。接下来几个版本增加了使用 async 和 await 关键字定义异步函数的机制。

期约 (promise) 是对尚不存在结果的一个替身,ES6 针对规范推出了 Promise 类型。Promise 类型通过 new 操作符进行实例化,创建新期约时需要传入执行器 (executor) 函数作为参数:

let p = new Promise(() => {});
setTimeout(console.log, 0, p);  // Promise <pending>

期约是一个有状态的对象,可能处于如下 3 种状态之一:

待定 (pending) 是期约的最初始状态。在待定状态下,期约可以落定 (settled) 为代表成功的兑现 (fulfilled) 状态,或者代表失败的拒绝 (rejected) 状态。无论落定为哪种状态都是不可逆的,即只要从待定转换为兑现或拒绝,期约的状态就不再改变。不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决 (resolve) 还是拒绝 (reject) ,甚至永远处于待定 (pending) 状态,都应该具有恰当的行为。

由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为 resolve() 和 reject()。调用 resolve() 会把状态切换为兑现,调用 reject() 会把状态切换为拒绝。

let p1 = new Promise((resolve, reject) => resolve()); setTimeout(console.log, 0, p1); // Promise <resolved>

let p2 = new Promise((resolve, reject) => reject()); setTimeout(console.log, 0, p2); // Promise <rejected> 
// Uncaught error (in promise)

每个期约只要状态切换为兑现,就会有一个私有的内部值(value)。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由(reason)。

期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用 Promise.resolve() 静态方法,可以实例化一个解决的期约:

let p1 = new Promise((resolve, reject) => resolve());
// 等价于    
let p2 = Promise.resolve();

Promise.resolve() 接收一个参数,用于表示期约的 value

setTimeout(console.log, 0, Promise.resolve());
// Promise <resolved>: undefined
setTimeout(console.log, 0, Promise.resolve(3));
// Promise <resolved>: 3
// 多余的参数会忽略
setTimeout(console.log, 0, Promise.resolve(4, 5, 6)); 
// Promise <resolved>: 4

与 Promise.resolve() 类似,Promise.reject() 会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过 try/catch 捕获,而只能通过拒绝处理程序捕获)。

let p1 = new Promise((resolve, reject) => reject());
// 等价于
let p2 = Promise.reject();

拒绝期约的理由就是传给 Promise.reject()的第一个参数。这个参数也会传给后续的拒 绝处理程序:

let p = Promise.reject(3);
setTimeout(console.log, 0, p); // Promise <rejected>: 3
p.then(null, (e) => setTimeout(console.log, 0, e)); // 3

Promise.prototype.then() 是为期约实例添加处理程序的主要方法。then() 方法接收最多两个参数:onResolved 和 onRejected。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。

function onResolved(id) {
  setTimeout(console.log, 0, id, 'resolved');
}
function onRejected(id) {
  setTimeout(console.log, 0, id, 'rejected');
}
let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000)); 
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));

p1.then(() => onResolved('p1'),
        () => onRejected('p1'));
p2.then(() => onResolved('p2'),
        () => onRejected('p2'));
//(3 秒后)
// p1 resolved 
// p2 rejected

onResolved 和 onRejected 参数是互斥的,且参数是可选的,如果只想提供 onRejected 参数,那么就要在 onResolved 参数的位置传入 undefined。

Promise.prototype.catch() 方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序,相当于调用 Promise.prototype. then(null, onRejected)。

let p = Promise.reject();
let onRejected = function(e) {
  setTimeout(console.log, 0, 'rejected');
};
// 这两种添加拒绝处理程序的方式是一样的 
p.then(null, onRejected); // rejected 
p.catch(onRejected); // rejected

Promise.prototype.finally() 方法用于给期约添加 onFinally 处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

let p1 = Promise.resolve();
let p2 = Promise.reject();
let onFinally = function() {
  setTimeout(console.log, 0, 'Finally!');
}
p1.finally(onFinally); // Finally
p2.finally(onFinally); // Finally

Promise 类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all() 和 Promise.race()。

Promise.all() 静态方法创建的期约会在一组期约全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新期约:

let p1 = Promise.all([
  Promise.resolve(),
  Promise.resolve()
]);

如果至少有一个包含的期约待定,则合成的期约也会待定;如果有一个包含的期约拒绝,则合成的期约也会拒绝:

// 永远待定
let p1 = Promise.all([new Promise(() => {})]); setTimeout(console.log, 0, p1); // Promise <pending>

// 一次拒绝会导致最终期约拒绝 
let p2 = Promise.all([
  Promise.resolve(),
  Promise.reject(),
  Promise.resolve()
]);
setTimeout(console.log, 0, p2); // Promise <rejected>
// Uncaught (in promise) undefined

如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序:

let p = Promise.all([
  Promise.resolve(3),
  Promise.resolve(),
  Promise.resolve(4)
]);
p.then((values) => setTimeout(console.log, 0, values)); 
// [3, undefined, 4]

如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作。

let p = Promise.all([
  Promise.reject(3),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
p.catch((reason) => setTimeout(console.log, 0, reason)); // 3

Promise.race() 静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约:

let p1 = Promise.race([
  Promise.resolve(),
  Promise.resolve()
]);

Promise.race() 不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race() 就会包装其解决值或拒绝理由并返回新期约:

// 解决先发生,超时后的拒绝被忽略 
let p1 = Promise.race([
  Promise.resolve(3),
  new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
setTimeout(console.log, 0, p1); // Promise <resolved>: 3

// 拒绝先发生,超时后的解决被忽略 
let p2 = Promise.race([
  Promise.reject(4),
  new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
setTimeout(console.log, 0, p2); // Promise <rejected>: 4

// 迭代顺序决定了落定顺序
let p3 = Promise.race([
  Promise.resolve(5),
  Promise.resolve(6),
  Promise.resolve(7)
]);
setTimeout(console.log, 0, p3); // Promise <resolved>: 5

如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,合成的期约会静默处理所有包含期约的拒绝操作。

异步函数

通过 async / await 关键字实现异步函数,让以同步方式写的代码能够异步执行。先看下面的例子:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
p.then((x) => console.log(x));  // 3
// 或者通过定义函数的方式
function handler(x) { console.log(x); }
p.then(handler); // 3

async / await 旨在解决利用异步结构组织代码的问题。async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

async function foo() {}
let bar = async function() {};
let baz = async () => {};
class Qux { 
  async qux() {}
}

使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。不过,异步函数如果使用 return 关键字返回了值,这个值会被 Promise.resolve() 包装成一个期约对象。

async function foo() { 
  console.log(1); 
  return 3;
}
// 给返回的期约添加一个解决处理程序
foo().then(console.log);
console.log(2);
// 1 
// 2 
// 3

异步函数的返回值期待一个实现 thenable 接口的对象,但常规的值也可以。如果返回的是实现 thenable 接口的对象,则由提供给 then() 的处理程序“解包”。否则返回值被当做已经解决的期约进行处理。

await 关键字可以暂停异步函数代码的执行,等待期约解决。await 关键字期待一个实现 thenable 接口的对象,但常规的值也可以。如果是实现 thenable 接口的对象,则由 await 来“解包”。否则这个值被当做已经解决的期约进行处理。

await 关键字必须在异步函数中使用,在同步函数内部使用 await 会抛出 SyntaxError。

JS 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JS 会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。因此,即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。

async function foo() {
  console.log(2);
  await null;
  console.log(4);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3
// 4